Dark Mode for React Without a Theme Provider — Conditional CSS Variables, No Context, No Hydration Flash
Theming and Dark Mode in React Without a Provider
Salty CSS themes are plain CSS custom properties scoped to a parent selector. You declare two (or more) value sets under defineVariables' conditional scope, flip an attribute on an ancestor element (usually <html>), and every consumer of those tokens updates instantly.
That means no theme provider, no React context, no <ThemeProvider> wrapper, no re-render on switch, and no flash on hydration (with the inline-script pattern lower on this page). The same mechanism powers dark mode, high-contrast modes, brand themes — anything you can name with an attribute.
Theming vs. variables: In many CSS-in-JS libraries (Emotion, styled-components, Stitches) the word "theme" means the global design-token object — colors, spacing, typography. In Salty CSS, that concept is called variables. Theming in Salty CSS specifically refers to the runtime system described here: swapping named token sets by toggling an attribute on an ancestor element.
1. Declare the themes
Group your themed tokens under conditional.theme (the group name is yours to pick; theme is conventional). Each child key is one mode, and each mode declares the same set of token names.
// /styles/themes.css.ts import { defineVariables } from "@salty-css/core/factories"; export const themes = defineVariables({ conditional: { theme: { dark: { background: "{colors.black}", altBackground: "{colors.altBlack}", terminalBackground: "{colors.terminalBlack}", color: "{colors.white}", altColor: "{colors.altWhite}", highlight: "{colors.highlight}", }, light: { background: "{colors.white}", altBackground: "{colors.altWhite}", terminalBackground: "{colors.terminalWhite}", color: "{colors.black}", altColor: "{colors.altBlack}", highlight: "{colors.highlight}", }, brand: { background: "{colors.brand.main}", altBackground: "{colors.brand.muted}", terminalBackground: "{colors.brand.muted}", color: "{colors.white}", altColor: "{colors.altWhite}", highlight: "{colors.highlight}", }, }, }, });
The tokens reference static variables defined elsewhere ({colors.black}, {colors.white}, …) so the palette stays in one place. You're free to inline raw values too.
2. Consume the tokens
Reference them with the {theme.xxx} path — the group name becomes the namespace:
import { styled } from "@salty-css/react/styled"; export const Surface = styled("section", { base: { background: "{theme.background}", color: "{theme.color}", borderColor: "{theme.altBackground}", }, });
You can use the same tokens inside defineGlobalStyles, defineTemplates, className — anywhere a Salty style accepts a value.
3. Activate a theme
Set the matching attribute on an ancestor. The attribute name mirrors the group key (theme) and the value mirrors the mode key (dark or light):
<html data-theme="dark"> ... </html>
Every element under <html data-theme="dark"> now reads the dark values. Salty CSS emits the underlying rules (roughly [data-theme="dark"] { ... }) for you; you only need to set the attribute.
There are two common activation patterns — pick the one that fits your site:
4. Design-themed sections
Some sites use themes as a design tool rather than a user preference: a dark hero section, a light editorial body, a brand-colored CTA strip. For this pattern, set data-theme directly in your markup — no JavaScript required.
<section data-theme="dark"> <!-- dark hero --> </section> <section data-theme="light"> <!-- light content area --> </section> <section data-theme="brand"> <!-- brand-colored strip --> </section>
Attributes compose freely and can be nested. An element reads the nearest ancestor that carries a data-theme attribute, so a light page can contain a dark card, which can contain a light tooltip — each picks up the right values automatically.
This is the right choice when theming is a design decision driven by page structure, not a user preference. You wire up the markup once and the CSS does the rest.
5. OS preference and user toggle
When you want a site-wide dark/light mode that users can control, combine the data-theme attribute with a small script that reads the OS preference and persists the user's choice.
Three patterns below — pick one. For most sites with a toggle, the SSR + cookie approach (in Avoiding the flash) is the most reliable. For static sites, the inline-script toggle works. If you don't need a user-facing toggle at all, the pure-CSS option is the simplest.
Toggle
A theme toggle reads and writes a single attribute — here's a version that seeds from the OS preference and persists to localStorage:
// /components/theme-toggle.tsx "use client"; import { useEffect, useState } from "react"; type Theme = "dark" | "light"; const STORAGE_KEY = "theme"; const readInitial = (): Theme => { if (typeof window === "undefined") return "light"; const saved = window.localStorage.getItem(STORAGE_KEY) as Theme | null; if (saved === "dark" || saved === "light") return saved; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; export const ThemeToggle = () => { const [theme, setTheme] = useState<Theme>("light"); useEffect(() => { const initial = readInitial(); setTheme(initial); document.documentElement.dataset.theme = initial; }, []); const toggle = () => { const next: Theme = theme === "dark" ? "light" : "dark"; setTheme(next); document.documentElement.dataset.theme = next; window.localStorage.setItem(STORAGE_KEY, next); }; return ( <button type="button" onClick={toggle} aria-label="Toggle theme"> {theme === "dark" ? "☀ Light" : "☾ Dark"} </button> ); };
To avoid a flash of the wrong theme on first paint in Next.js, inline a tiny pre-hydration script in app/layout.tsx (or _document.tsx for the Pages Router) so the attribute is set before React hydrates:
// /app/layout.tsx const setThemeScript = ` try { var t = localStorage.getItem("theme"); if (t !== "dark" && t !== "light") { t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } document.documentElement.dataset.theme = t; } catch (e) {} `; export default function RootLayout({ children }) { return ( <html> <head> <script dangerouslySetInnerHTML= /> </head> <body>{children}</body> </html> ); }
OS-only (no toggle)
If you don't need a user-facing toggle and just want to follow the system preference automatically, use the simpler auto-theme helper:
// /components/auto-theme.ts "use client"; import { useEffect } from "react"; export function AutoTheme() { useEffect(() => { const query = window.matchMedia("(prefers-color-scheme: dark)"); const apply = (matches: boolean) => { document.documentElement.dataset.theme = matches ? "dark" : "light"; }; apply(query.matches); query.addEventListener("change", (event) => apply(event.matches)); return () => query.removeEventListener("change", apply as any); }, []); return null; }
Add <AutoTheme /> to your root layout. Because useEffect runs after hydration, users may see a brief flash of the default theme on the first load. To avoid the flash, add a pre-hydration inline script to your root layout — see Avoiding the flash on first paint.
Note: useEffect (React) and a regular <script> (Astro) both run after the initial paint, so this approach will produce a brief flash without a pre-hydration inline script. See Avoiding the flash on first paint below.
Pure CSS — no JavaScript at all
If you never need a user override, you can skip the conditional group entirely and define the themed tokens as responsive variables scoped to prefers-color-scheme. The values swap at the variable level — every consumer updates automatically, with zero JavaScript and no data-theme attribute to manage.
First, define the named media queries:
// /styles/media.css.ts import { defineMediaQuery } from "@salty-css/react/config"; export const prefersDark = defineMediaQuery((media) => media.dark);
Then declare the themed tokens under responsive:
// /styles/themes.css.ts import { defineVariables } from "@salty-css/core/factories"; export const themes = defineVariables({ responsive: { base: { theme: { background: "{colors.white}", color: "{colors.black}", altBackground: "{colors.altWhite}", }, }, "@prefersDark": { theme: { background: "{colors.black}", color: "{colors.white}", altBackground: "{colors.altBlack}", }, }, }, });
Consume them exactly like conditional theme tokens — the browser swaps the underlying values when the OS preference flips:
styled("section", { base: { background: "{theme.background}", color: "{theme.color}", }, });
The trade-off: there is no way to override the OS preference (no toggle, no data-theme="brand" sections). Reach for the conditional approach above as soon as you need either.
Theme-aware compound states
Use the same conditional tokens for interactive states so hover, focus, and disabled colors stay in sync with the active theme automatically:
import { styled } from "@salty-css/react/styled"; export const Button = styled("button", { base: { background: "{theme.background}", color: "{theme.color}", "&:hover": { background: "{theme.altBackground}", }, "&:disabled": { color: "{theme.altColor}", opacity: 0.5, }, }, });
Declare the extra variants (altBackground, altColor) in your conditional group alongside the base tokens — the browser resolves the right value for whatever theme is active.
Multiple, independent groups
Conditional groups are independent. Add a density group alongside theme and toggle the two attributes separately:
defineVariables({ conditional: { theme: { dark: { background: "#0a0a0a" }, light: { background: "#fafafa" }, }, density: { compact: { gap: "8px" }, spacious: { gap: "24px" }, }, }, });
<html data-theme="dark" data-density="compact"> ... </html>
The two attributes compose freely — four combinations from two groups, with no extra config.
Avoiding the flash on first paint
When the theme is applied by client-side JS, users can briefly see the wrong theme before the script runs. The goal is to have the correct data-theme on <html> before the browser paints the first pixel. Three routes to that, in order of reliability:
Best: SSR with a cookie (zero flash)
Store the user's preference in a cookie rather than localStorage. Cookies are sent with every request, so your server-side framework can read the preference and set data-theme in the rendered HTML before the page leaves the server — no client JS required, no flash ever.
Next.js (App Router) — read the cookie in your root layout server component and pass it to <html>:
// /app/layout.tsx import { cookies } from "next/headers"; export default async function RootLayout({ children }) { const theme = (await cookies()).get("theme")?.value ?? "light"; return ( <html data-theme={theme}> <body>{children}</body> </html> ); }
For a plain React SPA (Vite) without a server, skip to the inline-script approach below.
When the user toggles, update both the DOM attribute and the cookie so the server picks it up on the next request:
document.documentElement.dataset.theme = next; document.cookie = `theme=${next}; path=/; max-age=31536000; SameSite=Lax`;
Good: Inline script (static or SPA sites)
When SSR isn't available, inject a small synchronous script as early as possible in <head>. Because it's inline, the browser executes it before rendering anything.
The toggle snippets above already include this pattern: the Astro snippet uses is:inline, and the Next.js/React snippet includes a dangerouslySetInnerHTML pre-hydration script in the root layout.
Avoid initialising the theme inside useEffect or a deferred <script> — both run after paint and will produce a visible flash.
See also
- Variables — the foundation that
conditionalis part of. - Color function — derive shades from static color tokens.
- Media queries —
prefers-color-schemeandprefers-reduced-motion.