Skip to content

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.

The theme system supports three modes:

  • dark: Always use dark mode
  • light: Always use light mode
  • system: 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)
  1. 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 cookie
    const cookie = request.headers.get("Cookie");
    const match = cookie?.match(/theme=([^;]+)/);
    ctx.theme = (match?.[1] as "dark" | "light" | "system") || "system";
    },
    render(Document, [route("/", Home)]),
    ]);
  2. 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`,
    );
    }
  3. Update Document to set theme class before render

    The Document component 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" />
    <meta
    name="viewport"
    content="width=device-width, initial-scale=1"
    />
    <title>My App</title>
    </head>
    <body>
    {/* Script to set theme class before React hydrates */}
    <script
    dangerouslySetInnerHTML={{
    __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>
    );
    }
  4. 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 changes
    useEffect(() => {
    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 action
    setTheme(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 -> system
    if (theme === "system") {
    setThemeState("light");
    } else if (theme === "light") {
    setThemeState("dark");
    } else {
    setThemeState("system");
    }
    };
    return (
    <button
    onClick={toggleTheme}
    className="px-4 py-2 rounded bg-gray-200 dark:bg-gray-800"
    aria-label="Toggle theme"
    >
    {theme === "dark" ? "☀️" : theme === "light" ? "🌙" : "💻"}
    </button>
    );
    }
  5. Use the theme toggle in your pages

    Pass the theme from requestInfo to 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:

src/app/components/ThemeToggle.tsx
"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>
);
}

If you need to read the current theme in a client component, you can check the DOM directly:

src/app/components/MyComponent.tsx
"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>
);
}

With Tailwind CSS, you can use the dark: variant to style elements differently in dark mode:

src/app/styles.css
@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>

If you prefer using data attributes instead of class names, you can modify the Document and toggle component:

src/app/Document.tsx
// In the script
document.documentElement.setAttribute(
"data-theme",
shouldBeDark ? "dark" : "light",
);
src/app/components/ThemeToggle.tsx
// In the useEffect
root.setAttribute("data-theme", shouldBeDark ? "dark" : "light");

Then in your CSS:

[data-theme="dark"] .my-component {
background-color: #1a1a1a;
}