# Redwood Theme Switcher Add On This addon creates a Theme Switcher for a [RedwoodSDK](https://rwsdk.com) 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 ```shell 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: ```css @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: ```tsx import styles from "./styles.css?url"; ``` And add a `link` definition inside the `` tag: ```tsx ``` ### 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 ```tsx import { route, render, prefix, layout } from "rwsdk/router"; ``` and import the `ThemeLayout`: ```tsx 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: ```tsx render(Document, [ route("/", Home), prefix("/user", userRoutes) ] }, ``` Would become: ```tsx 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](https://github.com/ahaywood/example-theme-switcher-localStorage)). The biggest problem that most people run into is a Flash of Unwanted Content, or "FOUC". You can work around this by [injecting JavaScript](https://tailwindcss.com/docs/dark-mode#with-system-theme-support) directly into the ``. 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 ❌** ```tsx "use client"; import { ServerComponent } from "./ServerComponent"; export const ClientComponent = () => { return (
) } ``` **✅ THIS WILL WORK** ```tsx // ClientComponent.tsx "use client"; export const ClientComponent = ({children}) => { return (
{children}
) } // ServerComponent.tsx export const ServerComponent = ({children}) => { return ( {children} ) } ``` 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) ```css /* 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: ```css :root { --bg-color: white; --text-color: black; } ``` The section below that defines your dark theme styles: ```css 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: ```css 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` ```tsx 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. 2. The theme gets set by calling the `setTheme` function and passing in `light`, `dark`, or `system` For example: ```tsx ``` ### `ThemeProvider` The `ThemeProvider` contains [standard React Context](https://react.dev/learn/passing-data-deeply-with-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. ```tsx // 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: ```tsx
{children}
``` This applies the `theme` name as a `className` on the wrapping `div`. We're able to target this, either from our custom Tailwind directive: ```css @custom-variant dark (&:where(.dark, .dark *)); ``` Or within our standard CSS: ```css body:has(.dark), body:has(.system) { @media (prefers-color-scheme: dark) { ... } } ``` #### `useTheme` From our `ThemeProvider` component, we're also exporting a `useTheme` hook: ```tsx 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: ```tsx const { theme, setTheme } = useTheme(); ``` ### `ThemeLayout` The `ThemeLayout` is simplistic in nature. It gets the theme saved within our cookie ```tsx const theme = getCookie(requestInfo?.request ?? new Request(""), "theme"); ``` and passes it into the Provider, which wraps everything: ```tsx {children} ``` The `getCookie` function may look a little different than the standard JavaScript cookies ([W3 Schools Documentation](https://www.w3schools.com/js/js_cookies.asp)) 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("")` ```tsx 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: ```tsx 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.