Dark mode
Provide a persisted light and dark theme through context, apply it to the document root, and follow the system preference until the user chooses.
This recipe builds a ThemeProvider that holds the theme in a signal, shares it through createContext, saves the user's
choice to localStorage, and applies a dark class to <html> with useEffect. It demonstrates context with a required
provider, guarded browser APIs (isServer, typeof window, try/catch), and cleanup of a matchMedia listener.
The component#
The initial theme is the saved choice, else the system preference when matchMedia exists, else light. The user's choice
is saved by the setter, not by an effect: saving from an effect would write the system-derived initial value too and turn
"follow the system" into an explicit choice. Storage is a prop (ThemeStorage) so an application can supply its own persistence. The default is window.localStorage, read inside try because access can throw in some privacy modes.
import { createContext, isServer, onCleanup, onMount, useContext, useEffect, useStore } from "@shadow-js/core";
import type { ParentProps } from "@shadow-js/web";
export type Theme = "light" | "dark";
export interface ThemeStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface ThemeApi {
readonly theme: () => Theme;
readonly setTheme: (theme: Theme) => void;
readonly toggle: () => void;
}
const KEY = "theme";
const DARK_QUERY = "(prefers-color-scheme: dark)";
function browserStorage(): ThemeStorage | null {
if (isServer || typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function readSaved(storage: ThemeStorage | null): Theme | null {
if (storage === null) return null;
try {
const value = storage.getItem(KEY);
return value === "light" || value === "dark" ? value : null;
} catch {
return null;
}
}
function save(storage: ThemeStorage | null, theme: Theme): boolean {
if (storage === null) return false;
try {
storage.setItem(KEY, theme);
return true;
} catch {
return false;
}
}
function systemQuery(): MediaQueryList | null {
if (isServer || typeof window === "undefined") return null;
if (typeof window.matchMedia !== "function") return null;
return window.matchMedia(DARK_QUERY);
}
const ThemeContext = createContext<ThemeApi>();
export function ThemeProvider(props: ParentProps<{ storage?: ThemeStorage | undefined }>) {
const storage = props.storage ?? browserStorage();
const saved = readSaved(storage);
let chosen = saved !== null;
const initial: Theme = saved ?? (systemQuery()?.matches === true ? "dark" : "light");
const [theme, setThemeSignal] = useStore<Theme>(initial);
const setTheme = (next: Theme): void => {
chosen = true;
setThemeSignal(next);
save(storage, next);
};
const api: ThemeApi = {
theme,
setTheme,
toggle: () => setTheme(theme() === "dark" ? "light" : "dark"),
};
useEffect(() => {
document.documentElement.classList.toggle("dark", theme() === "dark");
});
onMount(() => {
const query = systemQuery();
if (query === null) return;
const onChange = (event: MediaQueryListEvent): void => {
if (!chosen) setThemeSignal(event.matches ? "dark" : "light");
};
query.addEventListener("change", onChange);
onCleanup(() => query.removeEventListener("change", onChange));
});
return <ThemeContext.Provider value={api}>{props.children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeApi {
return useContext(ThemeContext);
}
export function ThemeToggle() {
const { theme, toggle } = useTheme();
return (
<div>
<button type="button" aria-pressed={theme() === "dark"} onClick={toggle}>
Dark mode
</button>
<p>Current theme: {theme()}</p>
</div>
);
}
export function App() {
return (
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
}
Decisions#
Context without a default. createContext<ThemeApi>() has no default value, so useTheme() outside a
ThemeProvider throws a CONTEXT_MISSING error instead of returning a theme that does nothing. The value is read
once when the provider renders; it is an object of functions, and the reactivity is inside theme, which is a signal
accessor.
An effect for the DOM. useEffect reads theme(), so it re-runs on every change and toggles the class on
document.documentElement. Effects never run on the server and run after the first render, so document is available.
classList.toggle("dark", force) is idempotent, which keeps the effect safe to re-run.
Guards. isServer is false in browser builds and true in server builds, where window does not exist.
typeof window covers other non-DOM environments. typeof window.matchMedia covers DOMs that do not implement it.
The reads and writes of storage are inside try: localStorage can throw on access (blocked cookies), on setItem (quota exceeded), and getItem can return anything a previous version of the page stored, so the value is checked
against the two valid themes. On the server the guards yield no storage and no query, so the server picks light.
onMount plus onCleanup for matchMedia. The listener follows system changes only while chosen is false.
Once the user clicks the toggle, their choice wins for the rest of the session. The removal in onCleanup ties the
listener to the provider's lifetime.
Not in this recipe. A stored preference is applied after the first render, so a page that is shown before JavaScript runs can flash the wrong theme. To avoid that, set the class from a small script in the HTML head before the app loads.
Related: createContext, useContext,
useEffect, onMount, onCleanup,
isServer, useStore,
render.