Dark / Light Mode
This guide demonstrates how to implement dark and light mode themes in your RedwoodSDK application. The approach uses cookies to persist user preferences and direct DOM manipulation to toggle themes, without requiring a React context provider.
Overview
Section titled “Overview”The theme system supports three modes:
dark: Always use dark modelight: Always use light modesystem: Follow the user’s system preference
The implementation follows this flow:
worker (read theme from cookie) ⮑ Document (set class on <html>, calculate system theme before render) ⮑ Page components ⮑ ThemeToggle (client component that updates DOM and cookie)Implementation
Section titled “Implementation”-
Read theme from cookie in the worker
In your
src/worker.tsx, read the theme cookie and add it to the app context:src/worker.tsx import { defineApp, render, route, setCommonHeaders } from "rwsdk/router";import Document from "./app/Document";import Home from "./app/pages/Home";interface AppContext {theme?: "dark" | "light" | "system";}export default defineApp<AppContext>([setCommonHeaders(),({ ctx, request }) => {// Read theme from cookieconst cookie = request.headers.get("Cookie");const match = cookie?.match(/theme=([^;]+)/);ctx.theme = (match?.[1] as "dark" | "light" | "system") || "system";},render(Document, [route("/", Home)]),]); -
Create a server action to set the theme
Create a server function that updates the theme cookie:
src/app/actions/setTheme.ts "use server";import { requestInfo } from "rwsdk/worker";export async function setTheme(theme: "dark" | "light" | "system") {requestInfo.response.headers.set("Set-Cookie",`theme=${theme}; Path=/; Max-Age=31536000; SameSite=Lax`,);} -
Update Document to set theme class before render
The
Documentcomponent needs to set the theme class on the<html>element before React hydrates to prevent FOUC. This requires a small inline script:src/app/Document.tsx import type { DocumentProps } from "rwsdk/router";export default function Document({ children, requestInfo }: DocumentProps) {const theme = requestInfo?.ctx?.theme || "system";return (<html lang="en"><head><meta charSet="utf-8" /><metaname="viewport"content="width=device-width, initial-scale=1"/><title>My App</title></head><body>{/* Script to set theme class before React hydrates */}<scriptdangerouslySetInnerHTML={{__html: `(function() {const theme = ${JSON.stringify(theme)};const isSystemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;const shouldBeDark = theme === 'dark' || (theme === 'system' && isSystemDark);if (shouldBeDark) {document.documentElement.classList.add('dark');} else {document.documentElement.classList.remove('dark');}})();`,}}/>{children}</body></html>);} -
Create a theme toggle component
Create a client component that toggles the theme by directly manipulating the DOM and calling the server action:
src/app/components/ThemeToggle.tsx "use client";import { useEffect, useState } from "react";import { setTheme } from "../actions/setTheme";type Theme = "dark" | "light" | "system";export function ThemeToggle({ initialTheme }: { initialTheme: Theme }) {const [theme, setThemeState] = useState<Theme>(initialTheme);// Update DOM when theme changesuseEffect(() => {const root = document.documentElement;const shouldBeDark =theme === "dark" ||(theme === "system" &&window.matchMedia("(prefers-color-scheme: dark)").matches);if (shouldBeDark) {root.classList.add("dark");} else {root.classList.remove("dark");}// Persist to cookie via server actionsetTheme(theme);}, [theme]);// Listen for system theme changes when theme is "system"useEffect(() => {if (theme !== "system") return;const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");const handleChange = () => {const root = document.documentElement;if (mediaQuery.matches) {root.classList.add("dark");} else {root.classList.remove("dark");}};mediaQuery.addEventListener("change", handleChange);return () => mediaQuery.removeEventListener("change", handleChange);}, [theme]);const toggleTheme = () => {// Cycle through: system -> light -> dark -> systemif (theme === "system") {setThemeState("light");} else if (theme === "light") {setThemeState("dark");} else {setThemeState("system");}};return (<buttononClick={toggleTheme}className="px-4 py-2 rounded bg-gray-200 dark:bg-gray-800"aria-label="Toggle theme">{theme === "dark" ? "☀️" : theme === "light" ? "🌙" : "💻"}</button>);} -
Use the theme toggle in your pages
Pass the theme from
requestInfoto your toggle component:src/app/pages/Home.tsx import type { PageProps } from "rwsdk/router";import { ThemeToggle } from "../components/ThemeToggle";export default function Home({ requestInfo }: PageProps) {const theme = requestInfo?.ctx?.theme || "system";return (<div><h1>Welcome</h1><ThemeToggle initialTheme={theme} /></div>);}
Alternative: Simpler Toggle (Dark/Light Only)
Section titled “Alternative: Simpler Toggle (Dark/Light Only)”If you only need dark and light modes (no system preference), you can simplify the toggle:
"use client";
import { useEffect, useState } from "react";import { setTheme } from "../actions/setTheme";
export function ThemeToggle({ initialTheme,}: { initialTheme: "dark" | "light";}) { const [isDark, setIsDark] = useState(initialTheme === "dark");
useEffect(() => { const root = document.documentElement; if (isDark) { root.classList.add("dark"); setTheme("dark"); } else { root.classList.remove("dark"); setTheme("light"); } }, [isDark]);
return ( <button onClick={() => setIsDark(!isDark)} className="px-4 py-2 rounded bg-gray-200 dark:bg-gray-800" aria-label="Toggle theme" > {isDark ? "☀️" : "🌙"} </button> );}Reading the Current Theme
Section titled “Reading the Current Theme”If you need to read the current theme in a client component, you can check the DOM directly:
"use client";
import { useEffect, useState } from "react";
export function MyComponent() { const [isDark, setIsDark] = useState(false);
useEffect(() => { // Check if dark class is present setIsDark(document.documentElement.classList.contains("dark"));
// Optional: Listen for changes const observer = new MutationObserver(() => { setIsDark(document.documentElement.classList.contains("dark")); });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], });
return () => observer.disconnect(); }, []);
return ( <div> <p>Current theme: {isDark ? "dark" : "light"}</p> </div> );}CSS Styling
Section titled “CSS Styling”With Tailwind CSS, you can use the dark: variant to style elements differently in dark mode:
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
/* Your custom styles */.my-component { background-color: white; color: black;}
.dark .my-component { background-color: #1a1a1a; color: white;}Or with Tailwind utility classes:
<div className="bg-white dark:bg-gray-900 text-black dark:text-white"> Content</div>Alternative: Data Attributes
Section titled “Alternative: Data Attributes”If you prefer using data attributes instead of class names, you can modify the Document and toggle component:
// In the scriptdocument.documentElement.setAttribute( "data-theme", shouldBeDark ? "dark" : "light",);// In the useEffectroot.setAttribute("data-theme", shouldBeDark ? "dark" : "light");Then in your CSS:
[data-theme="dark"] .my-component { background-color: #1a1a1a;}Further Reading
Section titled “Further Reading”- Reference Implementation - Example from the invoices repository
- Tailwind CSS Dark Mode