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 { render, route } from "rwsdk/router";import { defineApp } from "rwsdk/worker";import { Document } from "@/app/Document";import { Home } from "@/app/pages/Home";export interface AppContext {theme?: "dark" | "light" | "system";}export default defineApp([({ 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 React from "react";import { requestInfo } from "rwsdk/worker";import stylesUrl from "./styles.css?url";export const Document: React.FC<{ children: React.ReactNode }> = ({children,}) => {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><link rel="modulepreload" href="/src/client.tsx" /><link rel="stylesheet" href={stylesUrl} /></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');}document.documentElement.setAttribute('data-theme', theme);})();`,}}/><div id="root">{children}</div><script>import("/src/client.tsx")</script></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, useRef, 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);const isInitialMount = useRef(true);// 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");}// Set data attribute for consistencyroot.setAttribute("data-theme", theme);// Persist to cookie via server action (only when theme actually changes, not on initial mount)if (!isInitialMount.current) {setTheme(theme).catch((error) => {console.error("Failed to set theme:", error);});} else {isInitialMount.current = false;}}, [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 (<div className="flex items-center gap-4"><span>Current theme: {theme}</span><buttononClick={toggleTheme}className="px-4 py-2 rounded bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 transition-colors"aria-label="Toggle theme">{theme === "dark" ? "☀️" : theme === "light" ? "🌙" : "💻"}</button></div>);} -
Use the theme toggle in your pages
Pass the theme from the context to your toggle component:
src/app/pages/Home.tsx import { RequestInfo } from "rwsdk/worker";import { ThemeToggle } from "../components/ThemeToggle";export function Home({ ctx }: RequestInfo) {const theme = ctx.theme || "system";return (<div><h1>Welcome</h1><ThemeToggle initialTheme={theme} /></div>);}
Reading the Current Theme
Section titled “Reading the Current Theme”The ThemeToggle component above includes a display of the current theme. If you need to read the current theme in a separate 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", theme);// In the useEffectroot.setAttribute("data-theme", theme);Then in your CSS:
[data-theme="dark"] .my-component { background-color: #1a1a1a;}Further Reading
Section titled “Further Reading”- Dark Mode Playground - Complete working example
- Tailwind CSS Dark Mode