Version 0.1.0 just released! Check out the release notes from GitHub Releases

Static-Export Dark Mode Without a Client Theme Provider — Just an Attribute Flip on documentElement

A data-theme Toggle for the Next.js App Router

Dark mode is the single most common ask for a CSS-in-JS library — and the place most of them ship runtime overhead. With Salty CSS, both themes are compiled into a single stylesheet keyed off a data-theme attribute on a root element. The "toggle" is just an attribute flip; no provider, no context, no extra bundle.

Declare the tokens once

Themes live as a conditional variable group — each leaf token resolves through var(--theme-…) and the variant set you pick wins via the data attribute selector on html.

// /styles/variables.css.ts
import { defineVariables } from "@salty-css/core/factories";

export default defineVariables({
  theme: {
    base: {
      background: "white",
      color: "#111",
      highlight: "#0070f3",
    },
    "[data-theme=dark]": {
      background: "#0b0d12",
      color: "#f5f5f5",
      highlight: "#7aa7ff",
    },
  },
});

Use the tokens in any React component

// /components/panel.css.ts
import { styled } from "@salty-css/react/styled";

export const Panel = styled("section", {
  base: {
    background: "{theme.background}",
    color: "{theme.color}",
    padding: "2rem",
    borderRadius: "0.5rem",
    transition: "background 0.2s ease, color 0.2s ease",
  },
});

Flip the attribute

The minimal "toggle" is a button that writes data-theme on documentElement. Persist it in localStorage, and inline a tiny pre-hydration script to avoid a flash of the wrong theme on first paint:

// /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>
  );
}

What ships to the browser

The compiler emits both theme branches into saltygen/index.css — one block of declarations under the base selector, one under [data-theme=dark]. The runtime cost of switching is zero JavaScript styling work: the browser re-resolves var(--theme-…) after the attribute changes. Nothing about Salty CSS is loaded on the client to make this work.

Gotchas

  • First-paint flash. Statically exported pages render with the default theme until JS runs. The pre-hydration script in the snippet above sets data-theme before React/Astro hydrates, so the correct theme is in place from the first frame.
  • System preference. prefers-color-scheme: dark is honoured by the read-from-storage logic in the snippet — the user's explicit choice always wins over the OS.
  • Server-rendered initial state. In the Next.js App Router, the root <html> is rendered on the server. You can't read localStorage there; render with no data-theme and let the inline script set it before paint.