Version 0.2.0 released! Check out the release notes from GitHub Releases

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:

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 conditional is part of.
  • Color function — derive shades from static color tokens.
  • Media queriesprefers-color-scheme and prefers-reduced-motion.